Skip to content

Fix client reconnection after page refresh#3117

Merged
FloPinguin merged 26 commits intomainfrom
rejoingame
Feb 9, 2026
Merged

Fix client reconnection after page refresh#3117
FloPinguin merged 26 commits intomainfrom
rejoingame

Conversation

@ryanbarlow97
Copy link
Contributor

@ryanbarlow97 ryanbarlow97 commented Feb 4, 2026

Description:

  • Removed all code related to generating a client ID on the client. The server now assigns the client ID and sends it to the client in lobby messages.

Please complete the following:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • I have added relevant tests to the test directory
  • I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced

Please put your Discord username so you can be contacted if a bug or regression is found:

w.o.n

@ryanbarlow97 ryanbarlow97 requested a review from a team as a code owner February 4, 2026 11:35
Copilot AI review requested due to automatic review settings February 4, 2026 11:35
@ryanbarlow97 ryanbarlow97 added the Bugfix Fixes a bug label Feb 4, 2026
@ryanbarlow97 ryanbarlow97 added this to the v30 milestone Feb 4, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 4, 2026

Walkthrough

Server identity now derives from persistent tokens: clientIDs are assigned and mapped from persistentIDs. Join/rejoin, kick, and intent flows use persistentID mappings and stamped intents. Server messages include myClientID and lobbyCreatorClientID; clients stop sending clientID and server stamps intents with the resolved clientID.

Changes

Cohort / File(s) Summary
Server core: ID mapping & join/rejoin
src/server/GameServer.ts, src/server/GameManager.ts
Adds persistentIdToClientId map and kickedPersistentIds set. getClientIdForPersistentId added. joinClient now returns `"joined"
Worker / WebSocket auth & reconnection
src/server/Worker.ts
Switches to Authorization Bearer token → creatorPersistentID. Validates tokens, derives persistentID for joins/rejoins, calls gm with persistentID, updates logging and close reasons, and forwards assigned clientID in server messages.
Schemas & typed intents
src/core/Schemas.ts
Removes BaseIntent reuse; each intent defined standalone. Introduces StampedIntentSchema/type (adds server-provided clientID). TurnSchema uses stamped intents. ServerLobbyInfoMessage and ServerStartGameMessage include myClientID and lobbyCreatorClientID. Client join/rejoin messages drop clientID.
Intent stamping & execution
src/core/execution/ExecutionManager.ts, src/client/LocalServer.ts
Runtime now accepts StampedIntent. LocalServer stamps outgoing intents with its clientID and includes myClientID in start/rejoin. ExecutionManager short-circuits to NoOpExecution for intents targeting missing players.
Client transport & payloads
src/client/Transport.ts, src/client/Auth.ts, src/client/Matchmaking.ts
Removed getClientIDForGame. Outgoing join/rejoin and intent payloads no longer include clientID; comments indicate server derives identity from token/persistentID.
Client runtime & runner changes
src/client/ClientGameRunner.ts, src/client/Main.ts
Removes clientID from public LobbyConfig. ClientGameRunner now receives clientID from server (myClientID) via constructor param; WorkerClient, GameView, and error flows updated to accept external clientID.
Lobby UI & modals
src/client/HostLobbyModal.ts, src/client/JoinLobbyModal.ts, src/client/SinglePlayerModal.ts
Host createLobby uses getPlayToken and Authorization header; Host subscribes to LobbyInfoEvent via EventBus and reads myClientID. Join modals no longer send clientID in join events. SinglePlayerModal delegates config to GameConfigForm and omits clientID from join payloads.
Local testing shim
src/client/LocalServer.ts
LocalServer keeps local clientID, enforces presence on start/rejoin, stamps intents, and includes myClientID in start/rejoin messages for local runs.
API and small constructor changes
src/server/Client.ts, src/server/Worker.ts
Removes isRejoin parameter from Client constructor. GameManager/Worker signatures updated to use persistentID where applicable; createGame now accepts creatorPersistentID.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    actor BrowserClient as "Browser Client"
    participant Worker as "Worker (WS)"
    participant GM as "GameManager"
    participant GS as "GameServer"
    participant PIDMap as "persistentIdToClientId"

    BrowserClient->>Worker: WS connect + Authorization: Bearer <token>
    Worker->>GS: validate token -> persistentID
    Worker->>GM: join/rejoin request (gameID, persistentID)
    GM->>GS: resolve clientID for persistentID
    GS->>PIDMap: lookup or create clientID for persistentID
    PIDMap-->>GS: clientID
    GS-->>GM: resolved clientID
    GM-->>Worker: join status + assigned clientID
    Worker->>BrowserClient: send `lobby_info` / `start` (includes myClientID, lobbyCreatorClientID)
    BrowserClient->>Worker: send intents (no clientID)
    Worker->>GS: forward intents
    GS->>GS: stamp intents with resolved clientID, enqueue/process turn
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

Tokens change hands and map to names,
Old sockets close, new sessions claim.
Intents are stamped, rejoin paths mend,
Creator found where persistentIDs send. 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix client reconnection after page refresh' directly matches the PR's primary objective: enabling clients to reconnect after a page refresh by shifting client ID assignment from client-side to server-side.
Description check ✅ Passed The description explains the core change (server-side client ID assignment) and documents completed tasks (screenshots, translations, tests, testing). It is clearly related to the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
src/server/Worker.ts (1)

340-359: Silent reconnect on "join" path re-sends all turns from 0.

Line 357 calls gm.rejoinClient(ws, persistentId, clientMsg.gameID) without a lastTurn argument, so it defaults to 0. For long-running games this means the entire turn history is serialized and sent over the wire on every page refresh.

Consider passing a lastTurn value here (like the "rejoin" path does on line 344) or at minimum add a comment explaining why replaying from turn 0 is intentional for this path.

src/server/GameServer.ts (2)

175-214: IP-limit rejection (line 213) silently returns "rejected" without sending an error message to the client.

The maxPlayers check (line 188-194) sends a "full-lobby" error message before returning "rejected", but the IP-limit check (lines 203-213) just returns "rejected" with no message. The client gets a ws.close(1002, "Lobby full") from Worker.ts either way, but the inconsistency means the IP-limited client gets no JSON error payload to distinguish the reason.

Consider sending a similar error message for the IP-limit case:

Proposed fix
     if (
       this.gameConfig.gameType === GameType.Public &&
       this.activeClients.filter(
         (c) => c.ip === client.ip && c.clientID !== client.clientID,
       ).length >= 3
     ) {
       this.log.warn("cannot add client, already have 3 ips", {
         clientID: client.clientID,
         clientIP: ipAnonymize(client.ip),
       });
+      client.ws.send(
+        JSON.stringify({
+          type: "error",
+          error: "ip-limit",
+        } satisfies ServerErrorMessage),
+      );
       return "rejected";
     }

45-49: persistentIdToClientId mapping is never cleaned up on kick or disconnect.

When a client is kicked (line 855, kickedPersistentIds.add), the corresponding entry in persistentIdToClientId remains. This is safe because getClientIdForPersistentId checks kickedPersistentIds before returning a value (line 171). However, the map grows monotonically for the lifetime of the game. For typical game sizes this is fine, but worth noting that persistentIdToClientId and allClients are never pruned — they serve as the game's historical record.

Also applies to: 238-239


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/server/GameServer.ts`:
- Around line 162-186: The reconnect branch in joinClient skips the persistentID
check that rejoinClient enforces, allowing session hijack; update the reconnect
path in joinClient to validate that incoming client.persistentID matches
existingClient.persistentID, and if it does not, log a warning and close/reject
the new socket (do not replace existingClient.ws or add to activeClients); if it
matches, continue with updating existingClient.ws, lastPing,
markClientDisconnected(..., false), addListeners(existingClient) and use
existingClient.ws when calling sendStartGameMsg.
- Around line 169-171: When replacing an existing client's WebSocket (the code
that sets existingClient.ws = client.ws and updates existingClient.lastPing),
first grab the previous WebSocket (e.g., const previousWs = existingClient.ws),
and if it exists and is not already CLOSED/closing, call previousWs.close()
(optionally with a normal close code) to avoid leaking the old connection; do
NOT call this.websockets.delete(previousWs) — leave Set cleanup to the class
end() method. Ensure you then assign existingClient.ws = client.ws and update
lastPing as before.
🧹 Nitpick comments (1)
src/server/GameServer.ts (1)

182-184: Use existingClient.ws for clarity.

After line 170, both client.ws and existingClient.ws point to the same WebSocket. Using existingClient.ws here would be more consistent and easier to follow since existingClient is what's added to activeClients.

✏️ Suggested change
   // Send start message if game has already started
   if (this._hasStarted) {
-    this.sendStartGameMsg(client.ws, 0);
+    this.sendStartGameMsg(existingClient.ws, 0);
   }

@github-project-automation github-project-automation bot moved this from Triage to Development in OpenFront Release Management Feb 4, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements client reconnection functionality after page refresh by modifying the joinClient method in GameServer.ts. Previously, when a client attempted to rejoin with the same clientID, a warning was logged and the client was rejected. The new code now handles reconnection by updating the WebSocket reference, restoring the client's connected status, and re-sending game state if needed.

Changes:

  • Modified joinClient to detect and handle reconnection scenarios when a client with the same clientID already exists
  • Added logic to update WebSocket references, mark client as connected, restore to activeClients, and resend game start information

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@ryanbarlow97 ryanbarlow97 marked this pull request as draft February 4, 2026 11:50
@ryanbarlow97 ryanbarlow97 marked this pull request as ready for review February 4, 2026 12:15
coderabbitai[bot]
coderabbitai bot previously approved these changes Feb 4, 2026
coderabbitai[bot]
coderabbitai bot previously approved these changes Feb 4, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/client/HostLobbyModal.ts (1)

638-666: ⚠️ Potential issue | 🟡 Minor

Set lobbyCreatorClientID after joining.

It now stays empty, so lobby-player-view cannot show the host or “you”. Consider setting it from the first client returned in pollPlayers (same as JoinLobbyModal) or from lobby_info.

✅ Suggested fix (pollPlayers)
-      .then((data: GameInfo) => {
-        this.clients = data.clients ?? [];
-      });
+      .then((data: GameInfo) => {
+        this.clients = data.clients ?? [];
+        if (!this.lobbyCreatorClientID && this.clients.length > 0) {
+          this.lobbyCreatorClientID = this.clients[0].clientID;
+        }
+      });
src/server/Worker.ts (1)

1-1: ⚠️ Potential issue | 🟡 Minor

Fix Prettier formatting before merge.

The CI pipeline reports formatting issues in this file. Run npx prettier --write src/server/Worker.ts to fix.

🤖 Fix all issues with AI agents
In `@src/client/ClientGameRunner.ts`:
- Around line 85-98: The file fails Prettier formatting; run the project's
formatter and commit the formatted changes to satisfy CI. Open
src/client/ClientGameRunner.ts (look for symbols like onconnect, onmessage,
terrainLoad, lobbyConfig) and apply the project's Prettier configuration (or run
npm/yarn script such as "npm run format" or "yarn format") to reformat the file,
then stage and push the updated file so the prettier check passes.

In `@src/client/JoinLobbyModal.ts`:
- Around line 346-350: startTrackingLobby currently clears this.currentClientID
and nothing sets it afterward; locate the handler that processes the server's
lobby_info message (or wherever yourClientID is received) and assign that value
into this.currentClientID (e.g., set this.currentClientID = message.yourClientID
or the equivalent field) so the lobby-player-view can identify the local player;
ensure the assignment happens after startTrackingLobby is called and consider
emitting/updating any dependent state or view update methods so the UI
re-renders with the new client id.

coderabbitai[bot]
coderabbitai bot previously approved these changes Feb 7, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/server/GameServer.ts (1)

193-211: ⚠️ Potential issue | 🟡 Minor

Rejected clients in joinClient leave unclosed sockets in the websockets Set.

At line 194, client.ws is added to this.websockets before any validation. When the client is rejected (full lobby at line 200, IP limit at line 225), the socket is never closed. While the websockets Set cleanup-in-end() pattern is intentional, rejected clients should still have their socket closed so they know to stop waiting.

The full-lobby path (line 204) sends an error message but doesn't close the socket. The IP-limit path (line 225) doesn't even send a message.

Close sockets for rejected clients
      client.ws.send(
        JSON.stringify({
          type: "error",
          error: "full-lobby",
        } satisfies ServerErrorMessage),
      );
+     client.ws.close(1000, "full-lobby");
      return;
    }
      this.log.warn("cannot add client, already have 3 ips", {
        clientID: client.clientID,
        clientIP: ipAnonymize(client.ip),
      });
+     client.ws.close(1008, "too many connections from same IP");
      return;
🧹 Nitpick comments (2)
src/client/HostLobbyModal.ts (1)

1072-1084: pollPlayers now overlaps with LobbyInfoEvent — consider removing it.

Both pollPlayers (HTTP polling every 1s at line 699) and handleLobbyInfo (via WebSocket LobbyInfoEvent) write to this.clients. The server already broadcasts lobby info every 1s via WebSocket. Having both can cause flickering if the HTTP response arrives slightly stale compared to the WebSocket push, or vice versa.

Since the WebSocket-based LobbyInfoEvent is the newer, more reliable source (and also provides lobbyCreatorClientID), the HTTP polling in pollPlayers looks redundant for the lobby phase.

src/server/GameServer.ts (1)

160-191: isKicked, getClientIdForPersistentId, getOrCreateClientId — well-structured identity resolution.

The layered approach is easy to follow:

  • isKicked resolves clientID → persistentID → kickedPersistentIds
  • getClientIdForPersistentId does map lookup + kick check
  • getOrCreateClientId wraps the above and generates a new ID when needed

One small note: getOrCreateClientId checks kickedPersistentIds directly at line 179, then calls getClientIdForPersistentId at line 183 which checks it again. The double check is harmless but redundant — getClientIdForPersistentId already returns null for kicked IDs.

Remove redundant kick check
  public getOrCreateClientId(persistentID: string): ClientID | null {
-   // Check if this persistentID has been kicked
-   if (this.kickedPersistentIds.has(persistentID)) {
-     return null;
-   }
-
    const existingClientID = this.getClientIdForPersistentId(persistentID);
    if (existingClientID) {
      return existingClientID;
    }
+
+   // Don't create new IDs for kicked players
+   if (this.kickedPersistentIds.has(persistentID)) {
+     return null;
+   }
+
    // Generate new clientID for new player
    const newClientID = generateID();
    this.persistentIdToClientId.set(persistentID, newClientID);
    return newClientID;
  }

coderabbitai[bot]
coderabbitai bot previously approved these changes Feb 7, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/server/GameServer.ts (2)

217-228: ⚠️ Potential issue | 🟡 Minor

IP-limit rejection silently drops the connection with no error and no socket close.

When the 3-IP limit is hit, the method returns without sending an error message or closing the WebSocket. The socket was already added to this.websockets at line 192, so it will linger until end(). The client has no idea what happened.

Consider sending an error message and closing the socket, similar to the full-lobby path.

Proposed fix
       this.log.warn("cannot add client, already have 3 ips", {
         clientID: client.clientID,
         clientIP: ipAnonymize(client.ip),
       });
+      client.ws.send(
+        JSON.stringify({
+          type: "error",
+          error: "too-many-connections",
+        } satisfies ServerErrorMessage),
+      );
+      client.ws.close(1008, "too many connections from same IP");
       return;

194-209: ⚠️ Potential issue | 🟡 Minor

Full-lobby rejection sends error but does not close the socket.

Same pattern as the IP-limit path: after sending the "full-lobby" error on line 202, client.ws stays open and lingers in this.websockets until end(). The client must handle the error and close on its own. Consider closing the socket server-side for a clean lifecycle.

Proposed fix
       client.ws.send(
         JSON.stringify({
           type: "error",
           error: "full-lobby",
         } satisfies ServerErrorMessage),
       );
+      client.ws.close(1000, "full-lobby");
       return;
🧹 Nitpick comments (2)
src/server/GameServer.ts (2)

167-189: getOrCreateClientId leaves an orphan mapping when joinClient later rejects the client.

If getOrCreateClientId creates a new persistentIdToClientId entry and then joinClient rejects the client (full lobby, IP limit), the mapping persists but allClients never gets populated for that clientID. This is not a bug today because rejoinClient checks allClients afterward (line 275–276), but it does mean persistentIdToClientId grows monotonically with every unique persistentID that ever attempted to join — even if they were rejected.

For a short-lived game server this is fine, but worth a note if game lifetimes ever grow long.


649-681: sendStartGameMsg finds the client by WebSocket reference — fragile if ws is shared or reused.

The lookup this.activeClients.find((c) => c.ws === ws) works today because each client has a unique ws. But if this method were ever called with a WebSocket that is not yet assigned (or after a reconnect race), the lookup silently fails and logs a warning (line 653).

A safer signature would accept client: Client directly (the caller always has it). This avoids the indirect lookup entirely.

Proposed change
-  private sendStartGameMsg(ws: WebSocket, lastTurn: number) {
-    // Find which client this websocket belongs to
-    const client = this.activeClients.find((c) => c.ws === ws);
-    if (!client) {
-      this.log.warn("Could not find client for websocket in sendStartGameMsg");
-      return;
-    }
+  private sendStartGameMsg(client: Client, lastTurn: number) {

Then update all call sites to pass client instead of client.ws / c.ws.

coderabbitai[bot]
coderabbitai bot previously approved these changes Feb 7, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/server/GameServer.ts (1)

175-217: ⚠️ Potential issue | 🟠 Major

State is mutated before rejection checks — stale mapping on reject.

Lines 180-181 add the WebSocket to this.websockets and set the persistentIdToClientId mapping before the full-lobby check (line 183) and the IP-limit check (line 206). When either check returns "rejected":

  1. persistentIdToClientId maps to a clientID that never enters allClients. A subsequent rejoinClient call will find the stale clientID, look it up in allClients, get undefined, and fail — so the player can't silently reconnect until they do a fresh join (which overwrites the mapping). Not catastrophic, but confusing.
  2. The WebSocket sits in this.websockets until end(), taking up memory for the entire game lifetime.

Also, the IP-limit rejection (line 216) sends no error message to the client, unlike the full-lobby path. The client has no idea why the connection went silent.

Proposed fix — move state mutation after all rejection checks
  public joinClient(client: Client): "joined" | "kicked" | "rejected" {
    if (this.kickedPersistentIds.has(client.persistentID)) {
      return "kicked";
    }

-   this.websockets.add(client.ws);
-   this.persistentIdToClientId.set(client.persistentID, client.clientID);
-
    if (
      this.gameConfig.maxPlayers &&
      this.activeClients.length >= this.gameConfig.maxPlayers
    ) {
      this.log.warn(`cannot add client, game full`, {
        clientID: client.clientID,
      });

      client.ws.send(
        JSON.stringify({
          type: "error",
          error: "full-lobby",
        } satisfies ServerErrorMessage),
      );
      return "rejected";
    }

    this.log.info("client joining game", {
      clientID: client.clientID,
      persistentID: client.persistentID,
      clientIP: ipAnonymize(client.ip),
    });

    if (
      this.gameConfig.gameType === GameType.Public &&
      this.activeClients.filter(
        (c) => c.ip === client.ip && c.clientID !== client.clientID,
      ).length >= 3
    ) {
      this.log.warn("cannot add client, already have 3 ips", {
        clientID: client.clientID,
        clientIP: ipAnonymize(client.ip),
      });
+     client.ws.send(
+       JSON.stringify({
+         type: "error",
+         error: "ip-limit",
+       } satisfies ServerErrorMessage),
+     );
      return "rejected";
    }

+   this.websockets.add(client.ws);
+   this.persistentIdToClientId.set(client.persistentID, client.clientID);
+
    if (this.config.env() === GameEnv.Prod) {
🤖 Fix all issues with AI agents
In `@src/server/Worker.ts`:
- Around line 452-463: joinClient can return "rejected" but Worker.ts doesn't
handle it, leaving the WebSocket open and stale entries created by
GameServer.joinClient (this.websockets and persistentIdToClientId). Update the
joinResult handling in Worker.ts to treat "rejected" like the other failure
cases: call ws.close(1002, "Cannot join game" or "Lobby full") and then remove
the socket and mappings that GameServer.joinClient already added (clear the
entry in GameServer.websockets for the new client id and delete the
persistentIdToClientId mapping for the persistent id) so there are no dangling
sockets or stale mappings after a rejection.

Copy link
Collaborator

@evanpelle evanpelle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@github-project-automation github-project-automation bot moved this from Development to Final Review in OpenFront Release Management Feb 8, 2026
@FloPinguin FloPinguin added this pull request to the merge queue Feb 9, 2026
@FloPinguin
Copy link
Contributor

(merged cuz evan probably forgot to click)

Merged via the queue into main with commit 8dcc7cf Feb 9, 2026
11 checks passed
@FloPinguin FloPinguin deleted the rejoingame branch February 9, 2026 01:11
@github-project-automation github-project-automation bot moved this from Final Review to Complete in OpenFront Release Management Feb 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bugfix Fixes a bug

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

Feature for coming back in game after being disconnected

3 participants